moduleResolution 总结

您所在的位置:网站首页 node modules干嘛的 moduleResolution 总结

moduleResolution 总结

2023-11-04 01:08| 来源: 网络整理| 查看: 265

模块化之争的在 ESM 标准出来的时候已成为定局,这两年前端界也在进行 ESM 大迁移。关于 ESM 个人感觉可以聊的并不多,最近讨论最多的可能还是到底该不该用 default import 以及它俩互相转换的各种坑。前不久 TS 发布了 5.0,引入了新的 moduleResolution:bundler。官方文档对此的描述非常简单,阅读完本文,你会对它的产生有更深刻的理解。

什么是 moduleResolution

当我们讨论模块化标准(对应的英文术语 module),我们更多的是讨论一个模块是如何声明使用导入/导出的语法。具体来说:

commonjs 使用 require 来导入,exports.xxx 来导出 esm 使用 import/export,使用 import('xxx') 来动态导入模块

而模块解析策略( moduleResolution)更多描述的是一个模块包括相对路径以及非相对路径(也就是第三方库,亦或者说 npm 包)是按照怎样的规则去查找的。相对路径没什么复杂的,不做讨论,本文主要聊聊第三方库的解析。

我们最熟悉的模块解析策略其实是 nodejs 的模块解析策略。第一次了解到还有别的模块解析策略还是在我刚学习 typescript 的时候。模块解析策略可以使用 tsconfig.json 的 moduleResolution 选项来配置,最早只支持两个值:classic 和 node。node 策略在 typescript 中又称之为node10 的解析策略。

moduleResolution: classic

You can use the moduleResolution option to specify the module resolution strategy. If not specified, the default is Node for --module commonjs, and Classic otherwise (including when module is set to amd, system, umd, es2015, esnext, etc.).

其实 classic 策略才是普通人最容易想到的模块解析策略,例如对于下面这个导入第三方依赖 pkg 的代码:

// 文件:/root/src/folder/index.js import 'pkg';

会经历下面的步骤来查找 pkg:

/root/src/folder/pkg.js /root/src/pkg.js /root/pkg.js /pkg.js

简单来说这种模块解析策略就是一直递归往上找同名文件,当前目录找不到同名文件就往父级目录找。不过这种策略目前前端界用得不多。

moduleResolution: node

写过 nodejs 的人应当非常熟悉了这个模块解析策略了,这个模块解析策略其实就是 nodejs 解析模块的策略,其实也就是 require.resolve 实现。

console.log(require.resolve('lodash')); // => /xxx/node_modules/.pnpm/[email protected]/node_modules/lodash/lodash.js

这也是各种前端构建工具如 webpack, vite 所采用的模块解析策略。这里没说 rollup 是因为rollup 默认没有内置模块解析策略,rollup 默认所有npm 包都是 external 的,你需要使用 node 模块解析策略的插件:@rollup/plugin-node-resolve。虽然说 vite 用的 nodejs 模块解析策略,但 vite 的实现并不完全和 nodejs 一致,其它工具也一样,你可以认为是对 nodejs 模块解析策略的扩展。不过如果说一个模块在 nodejs 中能正常解析,但它们解析不了,那肯定就算是 bug 了。

很多前端工具的 node 模块解析策略都不太一样:

vite 用的是第三方库 resolve.exports rollup 在 @rollup/plugin-node-resolve 自己实现的 webpack 用的 enhanced-resolve 不过还是 ljharb 大佬的 resolve 下载量最高,但有个很大的问题是不支持 package.json 的 exports

其实也有框架想通过优化这个解析速度来优化构建速度的,例如 rspack 用的 rust 模块 nodejs_resolver,其实也很好理解:

查找模块是构建过程的高频操作了,基本上每个文件都需要解析模块 id nodejs 的模块解析规则又比较复杂,并且是偏计算型的

用 rust 重写一遍大概率能得到比较可观的收益,rspack 核心作者给出的数据是速度 enhance-resolve 的 15 倍。

完整的 nodejs 解析策略可以看官方文档:module#all-together。

对于下面这段 nodejs 代码:

// 文件 /root/src/index.js require('pkg');

会按照下面的步骤来查找 pkg:

同级目录的 node_modules 找同名的 js 文件: /root/src/node_modules/pkg.js 同级目录 node_modules 里面找包含 package.json 的名为 pkg 文件夹:/root/src/node_modules/pkg/package.json 同级目录 node_modules 里面找包含 index.js 的名为 pkg 文件夹 /root/src/node_modules/pkg/index.js 还是找不到的话,那就往上一级目录重复前面的查找步骤 /root/node_modules/pkg.js /root/node_modules/pkg/package.json /root/node_modules/pkg/index.js

需要说明的是实际的查找过程还有很多细节我没写出来,例如解析 package.json 的 main 和 exports 字段等,这里只是为了大致描述 node 的解析过程。

其实上面的过程主要对应 nodejs 官方文档中的下面这段,不过要读懂官方文档还是需要一定的背景知识,有经验的读者还是建议完整阅读一下官方文档的。

LOAD_NODE_MODULES(X, START) 1. let DIRS = NODE_MODULES_PATHS(START) 2. for each DIR in DIRS: a. LOAD_PACKAGE_EXPORTS(X, DIR) b. LOAD_AS_FILE(DIR/X) c. LOAD_AS_DIRECTORY(DIR/X) NODE_MODULES_PATHS(START) 1. let PARTS = path split(START) 2. let I = count of PARTS - 1 3. let DIRS = [] 4. while I >= 0, a. if PARTS[I] = "node_modules" CONTINUE b. DIR = path join(PARTS[0 .. I] + "node_modules") c. DIRS = DIR + DIRS d. let I = I - 1 5. return DIRS + GLOBAL_FOLDERS

相比于 classic 策略的区别在于:

递归查找的目录是 node_modules,不是父级文件夹 引入了 package.json,各种配置项尤其是后面会展开说的 exports 字段使得 node 模块解析策略的变得非常复杂 支持文件夹模块,也就是 pkg/index.js,文件夹中包含 index.js,这个文件夹就是一个模块。

其它需要注意的点:

在讨论模块解析策略时,查找的文件类型不重要。css, png,html, wasm 文件都可以视为一个模块。 在哪个工具中查找模块也不重要。 tsc, nodejs, vite, esbuild, webpack, rspack 都需要处理 import/require,都需要解析模块,都需要选择一个查找模块的策略,而绝大多数都是使用 node 策略 node 的模块解析策略本身是不断变化的。例如说早期的 node 并不支持 package.json 的 exports 字段 调试模块解析 nodejs

当然最准确的还是看 nodejs 源码,debug nodejs 源码。菜鸡如我觉得太麻烦可以退而求其次 debug 一个实现 nodejs 解析策略的 npm package:

resolve.exports @rollup/plugin-node-resolve enhanced-resolve resolve

我写这篇博客时用来做各种测试的项目:module-resolution

typescript

tsc 有一个参数 --traceResolution 可以用来调试 tsc 查找 ts 文件的步骤。nodejs 没有找到类似的工具,有机会我来自己手动实现一遍 node 的解析策略,并输出每一步它在查找什么。tsc 虽然用的是 node 的解析策略,但是它还是有它自己的一些特殊性的,例如 ts 支持 node_modules/types 目录,package.json 支持 types, typings, typesVersions 等字段。

import { pow } from 'math/pow'; console.log(pow(1, 2)); ❯ tsc --traceResolution ======== Resolving module 'math/pow' from '/Users/yutengjing/code/module-resolution/apps/commonjs-ts-app/src/index.ts'. ======== Explicitly specified module resolution kind: 'NodeNext'. Resolving in CJS mode with conditions 'require', 'types', 'node'. File '/Users/yutengjing/code/module-resolution/apps/commonjs-ts-app/src/package.json' does not exist according to earlier cached lookups. File '/Users/yutengjing/code/module-resolution/apps/commonjs-ts-app/package.json' exists according to earlier cached lookups. Loading module 'math/pow' from 'node_modules' folder, target file types: TypeScript, JavaScript, Declaration. Directory '/Users/yutengjing/code/module-resolution/apps/commonjs-ts-app/src/node_modules' does not exist, skipping all lookups in it. Found 'package.json' at '/Users/yutengjing/code/module-resolution/apps/commonjs-ts-app/node_modules/math/package.json'. Entering conditional exports. Matched 'exports' condition 'types'. Using 'exports' subpath './*' with target './src/pow.ts'. File '/Users/yutengjing/code/module-resolution/apps/commonjs-ts-app/node_modules/math/src/pow.ts' exists - use it as a name resolution result. Resolved under condition 'types'. Exiting conditional exports. Resolving real path for '/Users/yutengjing/code/module-resolution/apps/commonjs-ts-app/node_modules/math/src/pow.ts', result '/Users/yutengjing/code/module-resolution/packages/math/src/pow.ts'. ======== Module name 'math/pow' was successfully resolved to '/Users/yutengjing/code/module-resolution/packages/math/src/pow.ts'. ======== 模块主入口

package.json 是前端绕不开的东西,很多前端工具都支持通过 package.json 来写配置。而在 node_modules 下,一个包含 package.json 的文件夹可以视为一个模块,我们可以通过 package.json来定义这个模块在被另一个模块导入时的解析规则。

main 字段

通过 main 字段来定义一个模块如何导出是目前最常见的做法了。拿全球下载量第一的 npm 包 lodash 来举例,它的 package.json 简化一下是这样的:

{ "name": "lodash", "version": "4.17.21", "main": "lodash.js" }

当没有其它字段时,node 在解析不含子路径的模块时就会找到 main 字段对应的文件。

那如果模块包含子路径时会怎样处理呢?例如:

const add = require('lodash/add'); lodash ├── add.js ├── fp │ └── add.js └── package.json

nodejs 会直接查找 node_modules/lodash/add.js,也就是说查找模块子路径非常简单粗暴。但如果你的项目不是像 lodash 那样把所有源码平铺到 package.json 同级,只使用 main 字段的情况下就没办法通过 lodash/add 来引用了。例如你把所有源码都丢到 src 目录,那你使用的时候就要写成:

const add = require('lodash/src/add');

这也解释了我一直以来的一个困惑:为啥 lodash 要把所有源码平铺到 package.json 同级,每次打开它的 github 主页就要等很长时间,找 package.json 也找半天,很不方便。原因我想就是为了处理导入子路径。

module 字段

为了解决某些库想同时提供 cjs 和 esm 两份 js 代码,我们可以使用 module 字段来指定 esm 版本的入口。例如 redux,简化后的 package.json:

{ "name": "redux", "version": "4.2.1", "main": "lib/redux.js", "unpkg": "dist/redux.js", "module": "es/redux.js", "typings": "./index.d.ts", "files": ["dist", "lib", "es", "src", "index.d.ts"] }

类似的字段还有很多,像上面写到的:

typings:和 types 是一样的作用,用来给 tsc 说明模块的类型声明入口。它俩相比我更建议用 typings: 首先 types 和另一个字段type 很接近,容易拼错。 另外,我们 ts 项目里面的 .d.ts 一般也放 typings 文件夹 ts-node 查找 .d.ts 默认也只找 typings 目录。 unpkg: 和 jsdeliver, cdn, browser 字段一样都是给 cdn 厂家用的,细节可以参考这个 issue: [What about cdn entry?](github.com/stereoboost…) vite 如何选择模块入口

vite 使用 esbuild 将 ts 文件转成 js 文件,esbuild 在转换时会直接丢弃 ts 类型,并不会做类型检查,所以它不用管类型怎样解析,也就不用处理 typings 等字段。

当同时存在 main 和 module入口,各种构建工具尤其是 rollup, vite 这些基于 ESM 的都是优先使用 module 字段。那如果只有 1 个 main 字段,使用 vite 会发生啥呢?

首先 vite 打包情况分很多种:

pre bundling: 使用 esbuild 预构建 esm dev server: vite 内置插件 vite:resolve 处理模块 id 解析 prod build: 生产环境构建,本质是 rollup + vite:resolve 插件 + @rollup/plugin-commonjs 插件

默认情况下,vite 预构建不管你第三方依赖支不支持 esm,都会给你打包。你可能会认为如果一个模块声明了 "type": "module",vite 就不会给你预构建,但实际上 vite 会的,应该是考虑类似 lodash-es 这样模块数量特多的依赖不预构建的话 http 请求数就太多了。

如果你不想预构建,就得手动将依赖添加到预构建 exclude 列表。当把一个依赖添加到预构建 exclude 列表,vite 就不会对它进行 commonjs -> esm 转换,即便把 main 字段指向一个 commonjs 模块,vite 还是会傻傻的把那个模块当 esm 模块处理。

vite 和 rollup 都是通过插件系统来增加自身的能力,它们都是先通过 resolve 插件确定一个模块的最终文件路径,再下一步使用 @rollup/plugin-commonjs 插件在需要转换的情况下给你转成 esm。如果同时存在 esm 的入口和通用入口,都会优先使用 esm 入口。

一些人可能会认为 main 入口是给 commonjs 专用的,其实不是,main 入口也可以给 esm 用,它是一个通用入口。另一个类似的还有 exports 中的 default 字段。

{ "exports": { ".": { "import": { "development": "./src", "import": "./dist/es/index.mjs", "require": "./dist/cjs/index.cjs", "default": "./dist/es/index.mjs" } } } } typesVersions

2023 年了,typescript 已然成为前端 er 的标配,即便你写的是 js,也能通过 jsdoc 充分感受的 ts 的强大和魅力。曾在知乎上看到有人吐槽说 ts 的类型系统过于复杂,在我看来,所谓的复杂其实某种程度上反映的是 TS 的强大和灵活。对于 ts,我现在最感到沮丧的反倒是它的性能,也不是说 tsc 构建性能,tsc 现在每个月还在投入精力优化的构建模式我也不是很感兴趣。我更希望优化的是编辑器代码提示的速度,稍微大点的项目有时能卡上好几秒才出提示。如果你没体过 vscode ts 代码提示的慢,可以试试在 VSCode 打开这个项目 github.com/nicoespeon/…,sematic token 的速度也不尽人意。写 vue 时经常肉眼可见一个变量从普通文本变成变量。最近一个消息挺有意思的,svelte 据说下一个大版本要从 ts 全面切回 js...

由于 ts 的流行,发布 npm 包的类型声明文件自然也成为了一个问题。目前主要有两种形式:

发布 types 包到 github.com/DefinitelyT…,目前有 8000+ 包采用这个方式 发布 npm 包时捆绑类型文件

使用 pnpm 安装依赖的时候有时候会看到这个警告:

 WARN  deprecated @types/[email protected]: This is a stub types definition. markdownlint provides its own type definitions, so you do not need this installed.

其实就是说这个 markdownlint 已经自己带了类型声明文件,你不用手动安装 @types/markdownlint 了。

我们可以观察一下它的 package.json 看看它是如何通知包管理器去做出这个提示的:

{ "name": "@types/markdownlint", "version": "0.18.0", "typings": null, "description": "Stub TypeScript definitions entry for markdownlint, which provides its own types definitions", "main": "", "dependencies": { "markdownlint": "*" } }

我猜可能是根据一个 types 包 @types/xxx 有没有 xxx 在 dependencies 中。

当我们发布一个 npm 包并且想要把类型声明文件一起发布的时候,一般情况下我们使用 typings 字段指向我们入口类型文件即可,例如 moment:

{ "name": "moment", "version": "2.29.4", "main": "./moment.js", "typings": "./moment.d.ts" } 子路径导出类型声明

如果你选择使用 types 包发布类型声明,那问题倒简单,你只需要像 @types/lodash 那样将类型声明文件按照导入的路径一样组织目录即可。

@types/lodash ├── add.d.ts ├── fp │ └── add.d.ts └── package.json

具体来说你导入语句是:

import add from 'lodash/add';

就需要存在 node_modules/@types/lodash/add.d.ts 这样的文件。如果你是像 node_modules/@types/lodash/src/add.d.ts 这样组织,把代码都放到 src 目录下,tsc 肯定是找不到的。

但如果你是选择类型声明和源码一起捆绑发布,还采用这种方式,把源码和类型声明混在一起,维护起来便会相当难受。

lodash ├── add.d.ts ├── add.js ├── fp │ ├── add.d.ts │ └── add.js └── package.json

我们来看看 unplugin-auto-import 是怎样做的,首先它的目录结构是这样:

. ├── auto-imports.d.ts ├── dist │ ├── astro.d.ts │ ├── esbuild.d.ts │ ├── index.d.ts │ ├── nuxt.d.ts │ ├── rollup.d.ts │ ├── types.d.ts │ ├── vite.d.ts │ ├── webpack.d.ts └── package.json

可以看到它的 .d.ts 没有平铺到 package.json 同级,那么现在问题就是怎样把类型声明从 unplugin-auto-import/vite 重定向到 unplugin-auto-import/dist/vite.d.ts 了。这就用到了 typesVersions 字段:

{ "name": "unplugin-auto-import", "version": "0.15.2", "types": "dist/index.d.ts", "typesVersions": { "*": { "*": ["./dist/*"] } } } 外层的 * 表示 typescript 的版本范围是任意版本 内层的 * 表示任意子路径,例如 unplugin-auto-import/vite 就对应 vite 整体表示在任意版本的 typescript 下,查找 unplugin-auto-import 的类型时,将查找路径重定向到 dist 目录。更详细的解释可以看官方文档:Version selection withtypesVersions。 注意我们这里 ./dist/* 没有写扩展名,如果 tsconfig.json 设置的 moduleResolution 是 node16 / nodenext,那就要改成 ./dist/*.d.ts

其实 typesVersions 设计目的并不是用来处理子路径导出的,这一点从它的名字就可以看出来,它是用来解决同一个包在不同版本的 typescript 下使用不同的类型声明,例如我们看 @types/node:

{ "name": "@types/node", "version": "18.15.11", "typesVersions": { "


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3